You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

376 lines
12 KiB

<script setup lang="ts">
import { unwrapApiBody, type ApiResponse } from '../../utils/http/factory'
import { extractFrontMatterDesc, stripFrontMatter } from '../../utils/markdown-front-matter'
import { buildPublicCanonicalUrl } from '../../utils/public-canonical-url'
import { safeExternalHref } from '../../utils/safe-external-href'
import { formatOccurredOnDisplay, formatPublishedDateOnly, occurredOnToIsoAttr } from '../../utils/timeline-datetime'
definePageMeta({
layout: 'public',
})
const route = useRoute()
const runtimeConfig = useRuntimeConfig()
const slug = computed(() => route.params.publicSlug as string)
type PublicPostListItem = {
title?: string | null
excerpt?: string | null
slug?: string | null
publishedAt?: Date | string | null
coverUrl?: string | null
}
type PublicTimelineItem = {
id?: number
title?: string | null
occurredOn?: Date | string | null
linkUrl?: string | null
bodyMarkdown?: string | null
}
type PublicRssListItem = {
title?: string | null
canonicalUrl?: string | null
canonical_url?: string | null
}
type ModulePayload<T> = {
items?: T[]
total?: number
}
type Payload = {
user: { publicSlug: string | null; nickname: string | null; avatar: string | null }
bio: { markdown: string } | null
links: { label: string; url?: string; href?: string; visibility: string; icon?: string }[]
modules?: {
posts?: ModulePayload<PublicPostListItem>
timeline?: ModulePayload<PublicTimelineItem>
reading?: ModulePayload<PublicRssListItem>
}
posts?: ModulePayload<PublicPostListItem>
timeline?: ModulePayload<PublicTimelineItem>
rssItems?: ModulePayload<PublicRssListItem>
}
type NormalizedModule<T> = {
items: T[]
total: number
}
function normalizeModule<T>(primary?: ModulePayload<T>, fallback?: ModulePayload<T>): NormalizedModule<T> {
const source = primary ?? fallback
return {
items: Array.isArray(source?.items) ? source.items : [],
total: typeof source?.total === 'number' ? source.total : 0,
}
}
function rssPublicHref(it: PublicRssListItem): string | undefined {
const u = it.canonicalUrl ?? it.canonical_url
return safeExternalHref(u)
}
function rssPublicTitle(it: PublicRssListItem): string {
const t = it.title
return typeof t === 'string' && t.trim().length ? t : '未命名'
}
function postPublicTitle(it: PublicPostListItem): string {
const t = it.title
return typeof t === 'string' && t.trim().length ? t : '未命名文章'
}
function postPublicExcerpt(it: PublicPostListItem): string {
const e = it.excerpt
return typeof e === 'string' ? e : ''
}
function postPublicSlug(it: PublicPostListItem): string {
const s = it.slug
return typeof s === 'string' ? s : ''
}
function timelinePublicTitle(it: PublicTimelineItem): string {
const t = it.title
return typeof t === 'string' && t.trim().length ? t : '未命名动态'
}
function timelinePublicBody(it: PublicTimelineItem): string {
const b = it.bodyMarkdown
return typeof b === 'string' ? b : ''
}
function timelineItemKey(e: PublicTimelineItem, i: number): string | number {
return e.id ?? i
}
function socialHref(link: { url?: string; href?: string }): string | undefined {
const value = link.url ?? link.href
return safeExternalHref(value, { allowMailto: true })
}
function rssHostname(href: string | undefined): string {
if (!href) {
return ''
}
try {
return new URL(href).hostname
}
catch {
return href
}
}
const { data, pending, error } = await useAsyncData(
() => `public-profile-${slug.value}`,
async () => {
const res = await $fetch<ApiResponse<Payload>>(`/api/public/profile/${encodeURIComponent(slug.value)}`)
return unwrapApiBody(res)
},
{ watch: [slug] },
)
const postsModule = computed(() => normalizeModule(data.value?.modules?.posts, data.value?.posts))
const timelineModule = computed(() => normalizeModule(data.value?.modules?.timeline, data.value?.timeline))
const readingModule = computed(() => normalizeModule(data.value?.modules?.reading, data.value?.rssItems))
const BIO_PREVIEW_MAX_CHARS = 140
const bioSummary = computed(() => {
const md = data.value?.bio?.markdown
if (!md?.trim()) {
return ''
}
const desc = extractFrontMatterDesc(md)
if (typeof desc === 'string' && desc.trim().length > 0) {
return desc.trim()
}
return stripFrontMatter(md).trim()
})
const bioPreviewText = computed(() => {
const plain = bioSummary.value.replace(/\s+/g, ' ').trim()
if (!plain) {
return ''
}
if (plain.length <= BIO_PREVIEW_MAX_CHARS) {
return plain
}
return `${plain.slice(0, BIO_PREVIEW_MAX_CHARS).trimEnd()}...`
})
const hasBioPreview = computed(() => bioPreviewText.value.length > 0)
const socialLinks = computed(() =>
(data.value?.links ?? [])
.map(link => ({ ...link, safeHref: socialHref(link) }))
.filter(link => typeof link.safeHref === 'string' && link.safeHref.length > 0),
)
const readingPreviewItems = computed(() =>
readingModule.value.items.slice(0, 2).map(item => ({
item,
href: rssPublicHref(item),
})),
)
const hasModuleContent = computed(() =>
postsModule.value.total > 0 || timelineModule.value.total > 0 || readingModule.value.total > 0,
)
const canonicalUrl = computed(() =>
buildPublicCanonicalUrl(runtimeConfig.public.siteUrl, route.fullPath),
)
useHead(() => ({
link: canonicalUrl.value
? [{ rel: 'canonical', href: canonicalUrl.value }]
: [],
}))
usePageTitle(() => {
const s = slug.value
const d = data.value
if (!d) {
return [`@${s}`, '主页']
}
const name = d.user.nickname || (d.user.publicSlug ? `@${d.user.publicSlug}` : '') || s
return [name, '主页']
})
</script>
<template>
<div v-if="pending && !data" class="text-muted py-10">
<UContainer>加载中…</UContainer>
</div>
<UContainer v-else-if="error && !data" class="py-10">
<UAlert color="error" title="无法加载主页" />
</UContainer>
<UContainer
v-else-if="data"
class="max-w-2xl py-10 sm:py-14"
>
<div class="mx-auto flex w-full max-w-xl flex-col gap-10 sm:gap-12">
<section
class="w-full rounded-2xl border border-default/80 bg-elevated/25 px-6 py-8 text-center shadow-sm ring-1 ring-black/5 dark:ring-white/10 sm:px-10 sm:py-9"
aria-label="个人资料"
>
<div class="flex flex-col items-center gap-4">
<div v-if="data.user.avatar" class="flex justify-center">
<img
:src="data.user.avatar"
alt=""
class="h-24 w-24 rounded-full border-2 border-default object-cover shadow-md ring-4 ring-primary/10"
>
</div>
<div class="space-y-1">
<h1 class="text-balance text-2xl font-semibold tracking-tight text-highlighted sm:text-3xl">
{{ data.user.nickname || data.user.publicSlug || slug }}
</h1>
<p
v-if="data.user.nickname && data.user.publicSlug"
class="text-sm font-medium tabular-nums text-muted"
>
@{{ data.user.publicSlug }}
</p>
</div>
<ul
v-if="socialLinks.length"
class="flex flex-wrap items-center justify-center gap-2.5"
aria-label="社交链接"
>
<li v-for="(l, i) in socialLinks" :key="i">
<a
:href="l.safeHref"
target="_blank"
rel="noopener noreferrer"
:title="l.label"
:aria-label="`${l.label}(新窗口打开)`"
class="flex size-10 items-center justify-center rounded-full border border-default/80 bg-default/40 text-primary transition-colors hover:border-primary/35 hover:bg-elevated focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<UIcon
:name="socialLinkIconName({ ...l, url: l.safeHref || '' })"
class="size-5 shrink-0 opacity-90"
/>
</a>
</li>
</ul>
</div>
</section>
<section
v-if="hasBioPreview"
class="w-full"
>
<NuxtLink
:to="`/@${slug}/about`"
class="block rounded-2xl border border-default/70 bg-elevated/20 p-5 transition-colors hover:border-primary/25 hover:bg-elevated/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary sm:p-6"
>
<p class="text-sm leading-relaxed text-toned sm:text-base">
{{ bioPreviewText }}
</p>
<p class="mt-3 text-xs font-medium text-primary">
查看完整介绍
</p>
</NuxtLink>
</section>
<template v-if="hasModuleContent">
<HubModuleCard
title="文章"
:total="postsModule.total"
description="最近发布的文章摘要,进入后可查看完整内容。"
:to="`/@${slug}/posts`"
>
<template #preview>
<NuxtLink
v-for="(p, i) in postsModule.items.slice(0, 2)"
:key="`${postPublicSlug(p)}-${i}`"
:to="`/@${slug}/posts/${encodeURIComponent(postPublicSlug(p))}`"
class="block rounded-lg border border-default/80 bg-default/30 px-3 py-2 transition-colors hover:bg-elevated/40"
>
<div class="text-sm font-medium text-highlighted">
{{ postPublicTitle(p) }}
</div>
<time
v-if="p.publishedAt"
class="mt-1 block text-xs tabular-nums text-muted"
:datetime="occurredOnToIsoAttr(p.publishedAt)"
>{{ formatPublishedDateOnly(p.publishedAt) }}</time>
<p v-if="postPublicExcerpt(p)" class="mt-1 line-clamp-2 text-xs text-muted">
{{ postPublicExcerpt(p) }}
</p>
</NuxtLink>
</template>
</HubModuleCard>
<HubModuleCard
title="时光机"
:total="timelineModule.total"
description="近期动态与节点,仅展示入口预览。"
:to="`/@${slug}/timeline`"
>
<template #preview>
<div
v-for="(e, i) in timelineModule.items.slice(0, 2)"
:key="timelineItemKey(e, i)"
class="rounded-lg border border-default/80 bg-default/30 px-3 py-2"
>
<div class="text-sm font-medium text-highlighted">
{{ timelinePublicTitle(e) }}
</div>
<time
v-if="e.occurredOn"
class="mt-1 block text-xs tabular-nums text-muted"
:datetime="occurredOnToIsoAttr(e.occurredOn)"
>{{ formatOccurredOnDisplay(e.occurredOn) }}</time>
<p v-if="timelinePublicBody(e)" class="mt-1 line-clamp-2 text-xs text-muted">
{{ timelinePublicBody(e) }}
</p>
</div>
</template>
</HubModuleCard>
<HubModuleCard
title="阅读"
:total="readingModule.total"
description="最近收藏/阅读条目,进入阅读页获取完整列表。"
:to="`/@${slug}/reading`"
>
<template #preview>
<template v-for="({ item, href }, i) in readingPreviewItems" :key="i">
<a
v-if="href"
:href="href"
target="_blank"
rel="noopener noreferrer"
class="block rounded-lg border border-default/80 bg-default/30 px-3 py-2 transition-colors hover:bg-elevated/40"
>
<div class="text-sm font-medium text-highlighted">
{{ rssPublicTitle(item) }}
</div>
<div class="mt-1 text-xs text-muted">
{{ rssHostname(href) }}
</div>
</a>
<div
v-else
class="rounded-lg border border-default/80 bg-default/30 px-3 py-2"
>
<div class="text-sm font-medium text-highlighted">
{{ rssPublicTitle(item) }}
</div>
</div>
</template>
</template>
</HubModuleCard>
</template>
<UEmpty
v-else
title="这里还没有公开内容"
description="站主尚未发布任何公开文章或动态。"
/>
</div>
</UContainer>
</template>